Praca domowa 2

Mateusz Krzyziński

Praca domowa nr 2 dotyczy metody wyjaśnień lokalnych LIME - Local Interpretable Model-Agnostic Explanations, która (podobnie jak metody Break Down i SHAP z poprzedniej pracy domowej) odpowiada na pytanie, jakie zmienne miały wpływ na otrzymaną predykcję wybranej obserwacji.

In [1]:
import pandas as pd
import numpy as np
import pickle
import dalex as dx
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
from sklearn.impute import SimpleImputer
import warnings
warnings.filterwarnings('ignore')

Dane

In [2]:
# Wczytanie i przygotowanie danych 
full_data = pd.read_csv("data/hotel_bookings.csv")
full_data["agent"] = full_data["agent"].astype(str)
treshold = 0.005 * len(full_data)
agents_to_change = full_data['agent'].value_counts()[full_data['agent'].value_counts() < treshold].index
full_data.loc[full_data["agent"].isin(agents_to_change), "agent"] = "other"

countries_to_change = full_data['country'].value_counts()[full_data['country'].value_counts() < treshold].index
full_data.loc[full_data["country"].isin(countries_to_change), "country"] = "other"


# Określenie cech uwzględnionych w modelu
num_features = ["lead_time", "arrival_date_week_number",
                "stays_in_weekend_nights", "stays_in_week_nights", 
                "adults", "previous_cancellations",
                "previous_bookings_not_canceled",
                "required_car_parking_spaces", "total_of_special_requests", 
                "adr", "booking_changes"]

cat_features = ["hotel", "market_segment", "country", 
                "reserved_room_type",
                "customer_type", "agent"]

features = num_features + cat_features

# Podział na zmienne wyjaśniające i target
X = full_data.drop(["is_canceled"], axis=1)[features]
y = full_data["is_canceled"]

categorical_names = {}
for feature in cat_features:
    col = X[[feature]]
    cat_transformer = SimpleImputer(strategy="constant", fill_value="Unknown")
    col = cat_transformer.fit_transform(col)
    X[feature] = col
    le = LabelEncoder()
    le.fit(X[[feature]])
    X[[feature]] = le.transform(X[[feature]])
    categorical_names[feature] = le.classes_
In [3]:
X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.2, random_state=42)

Model

Nastąpiło kilka zmian w porównaniu do modelu wykorzystanego w poprzedniej pracy domowej. Musieliśmy zastosować label encoding zmiennych kategorycznych. Stąd w powyższej sekcji w przygotowaniu danych obecne są operacje, które umożliwiają stworzenie słownika z wartościami odpowiadającymi poszczególnym kodom - categorical_names.

Sam nowy model wczytuję z pickle'a, a odpowiedniego notebooka dotyczącego modelowania można znaleźć już na repo.

In [4]:
rf_model = pickle.load(open("RF_model_le", 'rb'))

Wyjaśnienia

In [5]:
explainer = dx.Explainer(rf_model, X_train, y_train)
Preparation of a new explainer is initiated

  -> data              : 95512 rows 17 cols
  -> target variable   : Parameter 'y' was a pandas.Series. Converted to a numpy.ndarray.
  -> target variable   : 95512 values
  -> model_class       : sklearn.ensemble._forest.RandomForestClassifier (default)
  -> label             : Not specified, model's class short name will be used. (default)
  -> predict function  : <function yhat_proba_default at 0x7f9591d18b70> will be used (default)
  -> predict function  : Accepts only pandas.DataFrame, numpy.ndarray causes problems.
  -> predicted values  : min = 0.0, mean = 0.371, max = 1.0
  -> model type        : classification will be used (default)
  -> residual function : difference between y and yhat (default)
  -> residuals         : min = -0.943, mean = -0.00218, max = 0.957
  -> model_info        : package sklearn

A new explainer has been created!

1. Predykcja modelu dla wybranej obserwacji ze zbioru danych

Weźmy pod uwagę 2000-czną obserwację w zbiorze treningowym. Spójrzmy, jak wyglądają wartości w poszczególnych kolumnach.

In [6]:
X_train.iloc[2000]
Out[6]:
lead_time                         125.0
arrival_date_week_number           16.0
stays_in_weekend_nights             0.0
stays_in_week_nights                3.0
adults                              2.0
previous_cancellations              0.0
previous_bookings_not_canceled      0.0
required_car_parking_spaces         0.0
total_of_special_requests           0.0
adr                                85.0
booking_changes                     0.0
hotel                               0.0
market_segment                      4.0
country                            16.0
reserved_room_type                  0.0
customer_type                       2.0
agent                              22.0
Name: 66456, dtype: float64

country = 16.0 nie mówi zbyt wiele, dlatego przygotujemy funkcję, która będzie zwracać tekstowe wartości zmiennych dla wybranych obserwacji. Pomoże to też lepiej zrozumieć otrzymywane wyjaśnienia.

In [7]:
def get_cat_names(x):
    for feature in cat_features:
        print(f"{feature}: {categorical_names[feature][int(x[feature])]}")
In [8]:
get_cat_names(X_train.iloc[2000])
hotel: City Hotel
market_segment: Groups
country: PRT
reserved_room_type: A
customer_type: Transient
agent: other
In [9]:
print(f"Wyliczona predykcja dla wybranej obserwacji w zbiorze treningowym to: {rf_model.predict(X_train)[2000]}, \nPrawidłowa klasyfikacja to: {y_train.iloc[2000]}.")
Wyliczona predykcja dla wybranej obserwacji w zbiorze treningowym to: 1, 
Prawidłowa klasyfikacja to: 1.

Dla wybranej obserwacji model przewiduje target = 1, co oznacza, że rezerwacja zostanie odwołana. Jest to rzeczywiście odpowiednia wartość - rezerwacja ta jest oznaczona w zbiorze jako odwołana.

2. Wyliczenie dekompozycji predykcji modelu dla wcześniej wybranej obserwacji używając LIME

In [10]:
# Potrzebne do adnotowania, które kolumny powinny być traktowane przez LIME jako kategoryczne 
cat_features_columns = [X_train.columns.get_loc(c) for c in cat_features]
In [11]:
ps = explainer.predict_surrogate(X_train.iloc[2000,:], type = "lime", class_names=['not canceled', 'canceled'],
                                categorical_names = categorical_names, categorical_features = cat_features_columns)
In [12]:
ps.show_in_notebook()
In [13]:
ps.plot()
In [14]:
get_cat_names(X_train.iloc[2000,:])
hotel: City Hotel
market_segment: Groups
country: PRT
reserved_room_type: A
customer_type: Transient
agent: other
  • Prawdopodobieństwo predykcji równej 1 (odwołania) jest bardzo duże - 1.0 - model był jej prawie pewny.
  • Co ciekawe, nawet pomimo tego, że cecha, która została uznana za mającą największy wpływ (liczba wcześniejszych odwołań rezerwacji) wpływała na predykcję w drugą stronę.
  • Kolejne najważniejsze cechy wpływały na predykcję w stronę odwołania rezerwacji. Te cechy to pochodzenie - Portugalia (zgadza się z intuicją i tym, co pokazywała dekompozycja Break Down) i liczba zarezerwowanych miejsc parkingowych - 0 (to dość zaskakujący czynnik).
  • Kolejne cechy mają już ponad dwa razy mniejszy wpływ na ostateczną predykcję.
  • Spośród dziesięciu cech uznanych za najważniejsze osiem wpływało na predykcję 1.

3. Porównanie dekompozycji LIME dla różnych obserwacji w zbiorze

Oprócz otrzymanej już wyżej dekompozycji, weźmiemy pod uwagę obserwacje o różnych predykcjach i różnych pewnościach tych predykcji, a nawet jedną źle zaklasyfikowaną.

In [15]:
pd.DataFrame({"Prediction" : rf_model.predict(X_train.iloc[[3000, 2300, 4242, 83, 1410]]), "Target": y_train.iloc[[3000, 2300, 4242, 83, 1410]]})
Out[15]:
Prediction Target
82425 0 0
69469 1 1
29809 0 0
41414 1 0
14268 0 0
In [16]:
ps2 = explainer.predict_surrogate(X_train.iloc[3000,:], type = "lime", class_names=['not canceled', 'canceled'],
                                categorical_names = categorical_names, categorical_features = cat_features_columns)
In [17]:
ps2.show_in_notebook()
In [18]:
ps3 = explainer.predict_surrogate(X_train.iloc[2300,:], type = "lime", class_names=['not canceled', 'canceled'],
                                categorical_names = categorical_names, categorical_features = cat_features_columns)
In [19]:
ps3.show_in_notebook()
In [20]:
ps4 = explainer.predict_surrogate(X_train.iloc[4242,:], type = "lime", class_names=['not canceled', 'canceled'],
                                categorical_names = categorical_names, categorical_features = cat_features_columns)
In [21]:
ps4.show_in_notebook()
In [22]:
ps5 = explainer.predict_surrogate(X_train.iloc[83,:], type = "lime", class_names=['not canceled', 'canceled'],
                                categorical_names = categorical_names, categorical_features = cat_features_columns)
In [23]:
ps5.show_in_notebook()
In [24]:
ps6 = explainer.predict_surrogate(X_train.iloc[1410,:], type = "lime", class_names=['not canceled', 'canceled'],
                                categorical_names = categorical_names, categorical_features = cat_features_columns)
In [25]:
ps6.show_in_notebook()
In [26]:
ps6.plot()
In [29]:
get_cat_names(X_train.iloc[2300,:])
hotel: City Hotel
market_segment: Online TA
country: NLD
reserved_room_type: D
customer_type: Transient
agent: 9.0
In [30]:
get_cat_names(X_train.iloc[83,:])
hotel: City Hotel
market_segment: Offline TA/TO
country: ITA
reserved_room_type: A
customer_type: Transient-Party
agent: other
  • W każdej z powyższych dekompozycji najważniejszą zmienną okazuje się być previous_cancellations, która informuje o ilości wcześniej odwołanych rezerwacji przez danego klienta. Co więcej, dla tych samych wartości wkłady w predykcje są bardzo podobne lub takie same (brak wcześniejszych odwołań daje 0.35 w stronę nieodwołania).
  • Często wśród najważniejszych zmiennych obecna jest także required_car_parking_spaces. Podobnie jak wyżej - dla tej samej wartości daje ona bardzo podobne wyniki kontrybucji w predykcji.
  • Podobne wnioski możemy sformułować odnośnie zmiennej country. Zauważalne jest, że odgrywa ona tu jednak mniejszą rolę niż w przypadku dekompozycji Break Down. Wartym zauważenia jest to, że pochodzenie z Portugalii ma znacznie większy wpływ ze względu na wartość niż z innych krajów (w przykładach Włochy i Niderlandy).
  • Również na podstawie innych zmiennych o tych samych wartościach możemy zauważyć stabilność wyników - otrzymujemy wpływ na predykcje w tę samą stronę i z bardzo podobną wartością kontrybucji.
  • W kilku kategoriach widzimy, że dane zostały pogrupowane na dość "mało precyzyjne" przedziały, np. previous_bookings_not_canceled <=0 i >0 (z ostatniej obserwacji). Może to być sprzeczne z intuicją, bo jedna nieodwołana wcześniej obserwacja, a 10 jak w tym przykładzie robi z pozoru różnicę.
  • Na podstawie ostatniej obserwacji warto też zauważyć, że pomimo, że dwie najważniejsze cechy wskazują na predykcję w stronę 1 i są jeszcze 2 cechy kontrybuujące w tę stronę, to model z prawdopodobieństwem 0.99 wskazuje odpowiednią etykietę dla obserwacji.
  • Na podstawie przedostatniej obserwacji (źle zklasyfikowanej przez model) widzimy, że nawet pomimo, że najważniejsza zmienna kierowała w stronę dobrej predykcji i połowa uwzględnionych na wykresie zmiennych również, to ostateczna predykcja była w drugą stronę.

Podsumowanie

  • Wyjaśnianie lokalne metodą LIME jest szybsze niż wykorzystanie BreakDowna, a przede wszystkim SHAPa.
  • W przypadku długich nazw kolumn (jak w tym przypadku) wygenerowany środkowy wykres jest mało czytelny - nie jesteśmy w stanie określić przedziału w jakim zakwalifikowana została dana cecha wybranej obserwacji. Pomocne jest tu wykorzystanie metody .plot() zamiast .show_in_notebook().
  • Do użycia LIME'a konieczne było przerobienie naszego modelu tak, by nie używać one hot encodingu.
  • Szkoda, że nie otrzymujemy informacji o wartości kontrybucji do predykcji dla cech nieuwzględnionych na wykresie. Mogą one decydować o predykcji dla obserwacji, wobec której model ma małą pewność.
  • Dekompozycja i sama reprezentacja graficzna może być na pierwszy rzut oka myląca - większa liczba cech wpływających na predykcję w jedną stronę nie definiuje ostatecznej predykcji.
  • LIME jest stabilną metodą wyjaśnień lokalnych. Dla tej samej wartości cechy otrzymujemy bardzo podobną lub jednakową kontrybucję.
In [ ]: